package com.legsem.jhosync;

import java.sql.SQLException;
import java.util.Map;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.legsem.jhosync.dto.ApiParms;
import com.legsem.jhosync.dto.ClientCreate;
import com.legsem.jhosync.dto.ClientLogin;
import com.legsem.jhosync.face.Application;
import com.legsem.jhosync.face.SourceAdapter;
import com.legsem.jhosync.model.Client;
import com.legsem.jhosync.model.Model;
import com.legsem.jhosync.model.User;

/**
 * Server logic that is not related to the servlet engine.
 * <p/>
 * Concrete implementations must provide a property bag to hold the session
 * variables.
 * 
 */
public abstract class Server implements Context {

    final Logger logger = LoggerFactory.getLogger(Server.class);

    /** Locates the application implementation in the servlet context. */
    public static final String APPLICATION_IMPLEMENTATION_KEY = "com.legsem.jhosync.application";

    /** Locates the application name in the servlet context. */
    public static final String APPLICATION_NAME_KEY = "com.legsem.jhosync.application.name";

    /** Locates the source configurations in the servlet context. */
    public static final String SOURCE_CONFIGS_KEY = "com.legsem.jhosync.source.configs";

    /** Identifies the user in the session. */
    public static final String USER_LOGIN_KEY = "com.legsem.jhosync.user.login";

    /*
     * ------------------------------------------------------------------------
     * public methods
     * ------------------------------------------------------------------------
     */
    /**
     * Process a client login request.
     * <p/>
     * The administrator is treated differently from a regular rhosync client.
     * Administrator credentials are checked against the User table while Client
     * credentials are checked by Application specific code.
     * <p/>
     * Once authorized, a user is stored in the db (unless it is already there)
     * and the corresponding login name is stored in the session.
     * 
     * @param clientLogin user credentials
     * @return true if user is authorized
     * @throws ServerException if system is not setup properly
     */
    public boolean doLogin(ClientLogin clientLogin) throws ServerException {

        User user = null;
        try {
            if (clientLogin.getLogin().equals(Constants.ADMIN_LOGIN)) {
                user = authenticate(clientLogin.getLogin(),
                        clientLogin.getPassword());
            } else {
                if (getApplication().authenticate(clientLogin.getLogin(),
                        clientLogin.getPassword(), this)) {
                    user = User.find(clientLogin.getLogin());
                    if (user == null) {
                        user = new User(clientLogin.getLogin());
                        user.create();
                    }
                }
            }
        } catch (AuthenticationException e) {
            logger.debug("Login failed", e);
        } catch (SQLException e) {
            throw new ServerException(e);
        }

        if (user != null) {
            setSessionAttribute(USER_LOGIN_KEY, user.getLogin());
            // session.setAttribute("app_name", ?);

            logger.debug("User {} has logged in", clientLogin.getLogin());
            return true;
        } else {
            logger.debug("User {} was rejected", clientLogin.getLogin());
            return false;
        }

    }

    public void doLogout() {
        setSessionAttribute(USER_LOGIN_KEY, null);
    }

    /**
     * A client is requesting a new client ID. The server assigns new unique
     * user IDs that the client can later on use to identify himself.
     * 
     * TODO implement license check
     * 
     * TODO User should keep a list of clients. is this necessary? JMX counters?
     * 
     * @return client creation feedback
     * @throws ServerException if client can't be connected to a user
     */
    public ClientCreate doCreate() throws ServerException {

        try {
            User user = getCurrentUser();

            Client client = new Client(user.getLogin());
            client.create();
            logger.debug("Creating new client {} for user {}", client.getId(),
                    user.getLogin());

            return new ClientCreate(client, getSourceConfigs());
        } catch (SQLException e) {
            throw new ServerException(e);
        }
    }

    /**
     * A client is requesting that a previous client ID be wiped out.
     * <p/>
     * Destroying a client should cascade and destroy the client documents.
     * 
     * @param clientId user identifier
     * @return reset feedback (same as client creation)
     * @throws ServerException if client can't be connected to a user
     */
    public ClientCreate doReset(String clientId) throws ServerException {

        try {
            logger.debug("Resetting client {}", clientId);
            Client client = Client.find(clientId);
            if (client != null) {
                client.delete();
            }

            return new ClientCreate(null, getSourceConfigs());
        } catch (SQLException e) {
            throw new ServerException(e);
        }
    }

    /**
     * Client is requesting to query a source for data.
     * <p/>
     * 
     * @param clientId the client identifier
     * @param pSize the page size (maximum number of keys to return in one
     *            chunk)
     * @param sourceName the source name
     * @param token used to acknowledge previous exchanges
     * @param queryParams any additional query parameters
     * @return the updates operations that the client need to perform to get
     *         synched with the master document.
     */
    public ClientUpdatePages doQuery(String clientId, int pSize, int version,
            String sourceName, String token, Map < String, Object > queryParams)
            throws ServerException {

        try {
            ClientSync clientSync = getClientSync(clientId, pSize, sourceName);
            return clientSync.query(token, queryParams);
        } catch (SQLException e) {
            throw new ServerException(e);
        }

    }

    /**
     * Client is requesting to change the backend system.
     * <p/>
     * Change requests are expressed as create/update/delete operations.
     * 
     * @param source_name the source to update
     * @param createRequests the creates requested by the client
     * @param updateRequests the updates requested by the client
     * @param deleteRequests the deletes requested by the client
     * @throws ServerException if update fails
     */
    public void doCUD(String clientId, String source_name,
            Map < String, Map < String, Object > > createRequests,
            Map < String, Map < String, Object > > updateRequests,
            Map < String, Map < String, Object > > deleteRequests)
            throws ServerException {
        try {
            ClientSync clientSync = getClientSync(clientId, -1, source_name);
            clientSync.cud(createRequests, updateRequests, deleteRequests);
        } catch (SQLException e) {
            throw new ServerException(e);
        }
    }

    /**
     * Provides a token that subsequent calls can use to avoid checking for
     * authorization on each call.
     * 
     * @return the API token
     * @throws ServerException if user is not authorized
     */
    public String doApiGetToken() throws ServerException {
        User user = getCurrentUser();
        if (user != null && user.isAdmin() && user.getToken() != null) {
            return user.getToken();
        } else {
            throw new ServerException("User " + user + " is not authorized");
        }
    }

    /**
     * Resets the database to an empty state.
     * <p/>
     * The administrator properties are preserved.
     * 
     * @param apiParms the API parameters
     * @return a confirmation string
     * @throws ServerException if cleanup fails
     */
    public String doApiReset(ApiParms apiParms) throws ServerException {
        try {
            User adminUser = User.find(Constants.ADMIN_LOGIN);
            checkApiToken(apiParms, adminUser);
            Model.initSchema();
            adminUser.update();

            return "DB reset";
        } catch (SQLException e) {
            throw new ServerException(e);
        }

    }

    /*
     * ------------------------------------------------------------------------
     * protected methods
     * ------------------------------------------------------------------------
     */

    /**
     * A client request is serviced by the ClientSync class. Here we check that
     * request parameters, update the general context and create a new instance
     * of the ClientSync class.
     * <p/>
     * Client should have logged in as a user and should have created a client
     * id.
     * <p/>
     * Client is authorized to reuse a previous client id even if he logged in
     * with a different user id than the one he used to create the client id
     * originally so we might need to witch users to reflect that.
     * 
     * 
     * @param clientId the client identifier
     * @param p_size the page size (maximum number of keys to return in one
     *            chunk)
     * @param source_name the source name
     * @return a new client synchronizing class
     * @throws ServerException if something goes wrong
     */
    protected ClientSync getClientSync(String clientId, int p_size,
            String source_name) throws ServerException {
        if (clientId == null) {
            throw new ProtocolException(
                    "No clientId provided. Unable to identify client");
        }
        if (source_name == null || source_name.length() == 0) {
            throw new ProtocolException(
                    "Can't load source, no source_name provided");
        }
        SourceConfig sourceConfig = getSourceConfigs().get(source_name);
        if (sourceConfig == null) {
            throw new ProtocolException("Source name " + source_name
                    + " is unknown");
        }

        Client client;
        try {
            client = Client.find(clientId);
        } catch (SQLException e) {
            throw new ServerException(e);
        }
        if (client == null) {
            throw new ProtocolException("Client id " + clientId + " is unknown");
        }

        updateClientUser(client);

        return new ClientSync(getApplicationName(), client, sourceConfig,
                p_size, this);
    }

    /**
     * Retrieve a developer implemented Application.
     * 
     * @return a developer implemented Rhosync Application
     * @throws ConfigurationException if Application code cannot be retrieved
     */
    protected Application getApplication() throws ConfigurationException {
        Application application = (Application) getConfigAttribute(APPLICATION_IMPLEMENTATION_KEY);
        if (application == null) {
            throw new ConfigurationException(
                    "Unable to locate a class implementing "
                            + Application.class.getName() + " on the classpath");
        }
        return application;
    }

    /**
     * Retrieve the application name.
     * 
     * @return the application name
     * @throws ConfigurationException if application not setup correctly
     */
    protected String getApplicationName() throws ConfigurationException {
        String applicationName = (String) getConfigAttribute(APPLICATION_NAME_KEY);
        if (applicationName == null) {
            throw new ConfigurationException(
                    "Unable to locate a the application name");
        }
        return applicationName;
    }

    /**
     * Retrieve a list of developer implemented sources.
     * <p/>
     * Assumes configured sources were identified upstream.
     * <p/>
     * Loads the sources code and, since this is an expensive
     * 
     * @return a list of developer implemented sources
     * @throws ConfigurationException if sources list cannot be retrieved
     */
    @SuppressWarnings("unchecked")
    protected Map < String, SourceConfig > getSourceConfigs()
            throws ConfigurationException {
        Map < String, SourceConfig > sourceConfigs = (Map < String, SourceConfig >) getConfigAttribute(SOURCE_CONFIGS_KEY);
        if (sourceConfigs == null) {
            throw new ConfigurationException(
                    "Unable to locate classes implementing "
                            + SourceAdapter.class.getName()
                            + " on the classpath");
        }
        return sourceConfigs;
    }

    /**
     * Retrieves the User.
     * <p/>
     * Assumes the user was bound to the session by a previous call.
     * 
     * @return a user object
     * @throws ServerException if no user logged in
     */
    protected User getCurrentUser() throws ServerException {
        try {
            String login = (String) getSessionAttribute(USER_LOGIN_KEY);
            if (login == null) {
                throw new AuthenticationException("No user is logged in");
            }
            User user = User.find(login);
            if (user == null) {
                throw new AuthenticationException("User " + login
                        + " is not logged in");
            }
            return user;
        } catch (SQLException e) {
            throw new ServerException(e);
        }
    }

    /**
     * If the user is reusing a previous client id with new credentials, we need
     * to update the client object to reflect the new relationship with the
     * user.
     * 
     * @param client the client object
     */
    protected void updateClientUser(Client client) throws ServerException {
        try {
            String login = (String) getSessionAttribute(USER_LOGIN_KEY);
            if (login != null && !client.getUserLogin().equals(login)) {
                client.setUserLogin(login);
                client.update();
            }
        } catch (SQLException e) {
            throw new ServerException(e);
        }
    }

    /**
     * Authenticate user credentials against the User table.
     * 
     * @param login the user ID
     * @param password the user password
     * @return the authenticated user
     * @throws ServerException if credentials are invalid
     */
    protected User authenticate(String login, String password)
            throws ServerException {
        try {
            if (login == null || login.equals(Constants.SHARED_LOGIN)) {
                throw new AuthenticationException("User " + login
                        + " cannot be used for authentication");
            }
            User user = User.find(login);
            if (user == null) {
                throw new AuthenticationException("User " + login
                        + " does not exist");
            }
            if (User.encrypt((password == null) ? "" : password, user.getSalt())
                    .equals(user.getHashed_password())) {
                return user;
            } else {
                throw new AuthenticationException("Invalid password for user "
                        + login);
            }
        } catch (SQLException e) {
            throw new ServerException(e);
        }

    }

    /**
     * Check that parameters contain an API token and that it matches the
     * administrator.
     * 
     * @param apiParms the API parameters
     * @param adminUser the administrator
     * @throws AuthenticationException if parameters does not contain a valid
     *             API token
     * @throws SQLException if database cannot be accessed
     */
    protected void checkApiToken(ApiParms apiParms, User adminUser)
            throws AuthenticationException, SQLException {
        if (apiParms.getApi_token() == null
                || !apiParms.getApi_token().equals(adminUser.getToken())) {
            throw new AuthenticationException("No API token provided");
        }
    }

    /*
     * ------------------------------------------------------------------------
     * abstract methods
     * ------------------------------------------------------------------------
     */

}
